Hexo 博客集成 AI 问答助手:DeepSeek API + Vercel Serverless + 悬浮球聊天面板

前言

ByteFisher 博客已发布 95+ 篇文章,一个痛点越来越明显:静态博客缺少即时互动能力

读者经常遇到的问题场景:

  • “有没有 Unity 相关的入门教程?”
  • “C# 委托的用法在哪篇文章讲过?”
  • “你们用的这个图床是什么?”

在之前,这些问题的唯一回答渠道是文章底部的评论区——读者留言,博主看到后回复,周期可能是几小时甚至几天。如果读者没有留下联系方式,即使回复了对方也看不到。

为了解决这个问题,我决定在博客右下角加一个 AI 问答助手悬浮球:读者点击即可提问,基于 DeepSeek 大模型实时获得回复。

本文记录完整的实现过程:后端代理、前端交互、Hexo 集成、Vercel 部署。


一、整体架构

AI 问答助手的架构分为三层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
┌─────────────────────────────────────────────────────────┐
│ 浏览器 (Frontend) │
│ ┌───────────────────┐ ┌───────────────────────────┐ │
│ │ source/js/ │ │ body-end.swig │ │
│ │ ai-assistant.js │ ← │ <script data-pjax> │ │
│ │ │ │ │ │
│ │ createBtn() │ │ styles.styl │ │
│ │ createPanel() │ │ main.css 编译注入 │ │
│ │ sendMessage() │ │ │ │
│ └────────┬──────────┘ └───────────────────────────┘ │
└───────────┼─────────────────────────────────────────────┘
│ POST https://bytefisher-ai.vercel.app/api/chat
│ JSON { messages: [...] }

┌─────────────────────────────────────────────────────────┐
│ Vercel Serverless (Proxy Layer) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ api/chat.js │ │
│ │ ├── setCorsHeaders() ← 动态 origin 检测 │ │
│ │ ├── 验证请求格式 ← messages 存在性检查 │ │
│ │ ├── 读取环境变量 ← DEEPSEEK_API_KEY │ │
│ │ ├── 转发到 DeepSeek ← POST /v1/chat/... │ │
│ │ └── 返回响应 ← JSON + CORS Header │ │
│ └──────────────────────────────────────────────────┘ │
└───────────────────────┬─────────────────────────────────┘
│ POST https://api.deepseek.com/v1/chat/completions
│ Authorization: Bearer sk-xxx

┌─────────────────────────────────────────────────────────┐
│ DeepSeek API │
│ ┌──────────────────────────────────────────────────┐ │
│ │ model: deepseek-chat │ │
│ │ messages: [system, user] │ │
│ │ temperature: 0.7 │ │
│ │ max_tokens: 2000 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

技术选型对比

方案 优点 缺点 选择理由
DeepSeek API 中文友好,¥0.14/百万tokens 成本极低,博客用得起
OpenAI API 生态完善 ¥2.5/百万tokens,贵 18 倍
本地 Ollama 免费 需要服务器资源
Vercel 免费额度,自动 HTTPS 冷启动 1-3s 已有 Vercel 账号(Waline)
Cloudflare Workers 免费额度更高 需额外注册

二、后端:Vercel 代理 API

2.1 为什么需要代理层

直接在前端调用 DeepSeek API 有两个问题:

  1. API Key 泄露 — 前端代码所有人可见,Key 会被盗用
  2. 无法干预 — 请求频率、日志、错误处理都不可控

解决方案:在 Vercel 上部署一个 Serverless Function 作为代理。API Key 存储在 Vercel 环境变量中,前端只与代理交互。代理层还可以处理 CORS、格式校验、错误标准化。

2.2 api/chat.js 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// api/chat.js — Vercel Serverless Function

// CORS 预处理:动态检测来源域名
function setCorsHeaders(req, res) {
var origin = req.headers.origin || '';
if (
origin === 'https://www.bytefisher.top' ||
origin.indexOf('localhost') !== -1 ||
origin.indexOf('127.0.0.1') !== -1
) {
res.setHeader('Access-Control-Allow-Origin', origin);
} else {
res.setHeader('Access-Control-Allow-Origin', 'https://www.bytefisher.top');
}
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}

module.exports = async (req, res) => {
setCorsHeaders(req, res);

// OPTIONS 预检请求
if (req.method === 'OPTIONS') {
return res.status(204).end();
}

if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}

var { messages } = req.body;
if (!messages || !messages.length) {
return res.status(400).json({ error: 'Messages required' });
}

var apiKey = process.env.DEEPSEEK_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: 'API key not configured' });
}

try {
var response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: messages,
temperature: 0.7,
max_tokens: 2000
})
});

var data = await response.json();

if (response.ok) {
res.status(200).json(data);
} else {
res.status(response.status).json({ error: data.error || 'API error' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
};

2.3 关键细节说明

CORS 动态 origin:本地开发时前端运行在 http://localhost:4000,如果 CORS 头写死为 https://www.bytefisher.top,浏览器会拦截请求。通过检测 req.headers.origin 可以实现本地开发和生产环境同时可用。

⚠️ 踩坑:module.exports 不是 export default:Vercel 默认按 CommonJS 解析 .js 文件。如果写成 export default async function handler(...),部署后会报语法错误,API 返回 500。

OPTIONS 预检:浏览器在跨域 POST 请求前会先发一个 OPTIONS 预检。如果不处理 OPTIONS,浏览器直接报 CORS 错误,真正的 POST 不会发出。

2.4 vercel.json

在项目根目录创建 vercel.json,控制 Serverless Function 的超时时间:

1
2
3
4
5
6
7
{
"functions": {
"api/*.js": {
"maxDuration": 10
}
}
}

Vercel 免费 Hobby 计划的 Serverless Function 最长执行 10 秒,超过会超时断开。


三、前端:悬浮球按钮

3.1 设计目标

  • 右下角固定定位,不干扰主内容
  • 默认显示 🎣 emoji
  • 悬停展开显示文字 “AI 助手”
  • 品牌色渐变背景
  • 点击后面板弹出,按钮隐藏

3.2 创建按钮

1
2
3
4
5
6
7
8
function createBtn() {
var btn = document.createElement('div');
btn.id = 'ai-assistant-btn';
btn.title = 'AI 问答助手';
btn.innerHTML = '<span class="ai-btn-icon">🎣</span><span class="ai-btn-text">AI 助手</span>';
btn.addEventListener('click', toggle);
document.body.appendChild(btn);
}

3.3 悬停展开动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#ai-assistant-btn
display: flex
align-items: center
gap: 4px
padding: 0 6px 0 14px
height: 44px
border-radius: 22px
background: linear-gradient(135deg, #37c6c0, #32b2ad)
color: #fff
cursor: pointer
z-index: 9998
box-shadow: 0 4px 16px rgba(55,198,192,0.35)
transition: padding 0.3s, opacity 0.3s, box-shadow 0.3s

.ai-btn-icon
font-size: 22px
line-height: 1

.ai-btn-text
font-size: 14px
white-space: nowrap
overflow: hidden
max-width: 0
opacity: 0
transition: max-width 0.3s, opacity 0.3s

&:hover
padding: 0 14px 0 14px
box-shadow: 0 6px 24px rgba(55,198,192,0.45)

.ai-btn-text
max-width: 80px
opacity: 1

&.hidden
opacity: 0
pointer-events: none

核心技巧:用 max-width + opacity 的 transition 实现文字展开。默认 max-width: 0 隐藏文字,悬停时设为 80px 并淡入。同时配合 padding 动画,药丸形状从紧凑变为舒展。


四、前端:聊天面板

4.1 面板布局

面板固定定位在右下角,与按钮同位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createPanel() {
var panel = document.createElement('div');
panel.id = 'ai-assistant-panel';
panel.innerHTML =
'<div class="ai-header">' +
'<span>🎣 ' + CONFIG.botName + '</span>' +
'<button class="ai-close">&times;</button>' +
'</div>' +
'<div class="ai-messages" id="ai-msgs"></div>' +
'<div class="ai-input-area">' +
'<textarea id="ai-input" rows="1" placeholder="' + CONFIG.placeholder + '"></textarea>' +
'<button id="ai-send">发送</button>' +
'</div>';
document.body.appendChild(panel);
}

4.2 消息气泡样式

采用对话式 UI 的经典风格:用户消息右对齐,机器人消息左对齐。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.ai-message
max-width: 85%
padding: 10px 14px
border-radius: 12px
font-size: 14px
line-height: 1.6

&.ai-message-user
background: #37c6c0
color: #fff
margin-left: auto
border-bottom-right-radius: 4px

&.ai-message-bot
background: #f0f4f8
color: #333
margin-right: auto
border-bottom-left-radius: 4px

code
background: rgba(0,0,0,0.06)
padding: 2px 6px
border-radius: 4px

pre
background: #1e1e1e
color: #d4d4d4
padding: 12px
border-radius: 8px
overflow-x: auto

4.3 Markdown 渲染引擎

不引入任何第三方库,纯前端正则实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function render(text) {
// 第一步:XSS 防护 — 转义 HTML 特殊字符
text = text.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// 第二步:代码块
text = text.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
// 第三步:行内代码
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// 第四步:加粗
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// 第五步:换行
text = text.replace(/\n/g, '<br>');
return text;
}

转义顺序很重要:先转义 HTML,再渲染 Markdown。否则用户输入 <script> 会绕过转义。

4.4 打字机加载动画

三点弹跳,错峰延迟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@keyframes ai-bounce
0%, 60%, 100%
transform: translateY(0)
30%
transform: translateY(-6px)

.ai-message-typing
background: #f0f4f8
display: flex
gap: 4px
align-items: center

span
width: 6px
height: 6px
border-radius: 50%
background: #ccc
animation: ai-bounce 1.4s infinite

&:nth-child(2)
animation-delay: 0.2s

&:nth-child(3)
animation-delay: 0.4s

4.5 键盘快捷键

1
2
3
4
5
6
7
8
9
10
11
12
// Enter 发送,Shift+Enter 换行
document.getElementById('ai-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
});

// Escape 关闭
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && isOpen) toggle();
});

五、System Prompt 工程

5.1 提示词设计原则

System Prompt 是 AI 助手的”人格设定”,直接影响回答质量。设计时遵循三个原则:

原则 说明 实现
身份明确 让模型知道自己是谁 “你是一个博客助手”
上下文充分 提供足够背景信息 作者、内容领域、文章数
约束清晰 限制回答风格和范围 “简洁中文、不确定不编造”

5.2 完整 Prompt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var system = [
'你是一个博客助手,帮助访客了解 ByteFisher 博客。',
'',
'## 博客基本信息',
'作者:淡水鱼(Unity 游戏开发者 + 钓鱼爱好者)',
'内容领域:Unity3D、C#、Lua、Python、钓鱼技巧、游戏开发教程',
'文章总数:95+ 篇',
'博客地址:https://www.bytefisher.top',
'',
'## 回答规则',
'- 使用简洁的中文回复,可以适当使用 emoji',
'- 不知道的内容不要编造',
'- 如果用户查找文章,引导他们使用搜索功能',
'- 回答控制在 200 字以内'
].join('\n');

5.3 边界情况处理

场景 处理方式
无关问题(如”今天天气怎么样”) 友好表示能力有限,引导回博客主题
敏感话题 礼貌拒绝
找不到相关信息 “抱歉没找到相关内容,换个问法试试?”
连续追问 当前设计为单轮问答(不保留历史),每次独立

六、Hexo / NexT 集成

6.1 样式注入

NexT 主题支持通过 source/_data/styles.styl 注入自定义样式。所有 AI 助手相关的 CSS 都追加在此文件末尾,会自动编译到 main.css 中。

6.2 脚本加载

source/_data/body-end.swig 末尾追加一行:

1
2
<!-- AI 博客助手 -->
<script src="/js/ai-assistant.js" data-pjax></script>

data-pjax 属性是关键:NexT 使用 PJAX 实现无刷新页面切换,加了此属性的脚本会在每次 PJAX 渲染时重新执行。

6.3 ⚠️ 踩坑:PJAX 重复创建

首次加载页面正常。但在 PJAX 导航到其他页面后,脚本重新执行,每次都创建一个新的悬浮球和面板,欢迎语跟着叠加。

1
2
3
4
5
6
7
  function init() {
+ if (document.getElementById('ai-assistant-btn')) return;
createBtn();
createPanel();
addMsg('bot', CONFIG.welcomeMessage);
autoResizeInput();
}

加一行防重复检查即可:如果按钮已存在,跳过创建。

6.4 文件清单

文件 操作 行数 说明
api/chat.js 新建 62 Vercel Serverless Function
vercel.json 新建 6 Vercel 配置
source/js/ai-assistant.js 新建 167 前端全部逻辑
source/_data/styles.styl 追加 ~120 AI 助手样式
source/_data/body-end.swig 追加 1 加载脚本标签

七、Vercel 部署指南

7.1 创建 Vercel 项目

1
2
3
4
5
6
7
8
9
10
11
12
1. 登录 https://vercel.com(与 Waline 同一个账号)
2. Dashboard → Add New → Project
3. 选择 BlogCode 仓库 → Import
4. Configure Project:
├── Framework Preset: Other
├── Root Directory: ./
├── Build Command: (留空)
└── Output Directory: (留空)
5. 点击 Environment Variables
├── Name: DEEPSEEK_API_KEY
└── Value: sk-xxxxxxxxxxxxxxxxx
6. 点击 Deploy,等待 1-2 分钟

7.2 配置前端 API 地址

部署完成后,Vercel 会分配一个域名,如 https://bytefisher-ai.vercel.app

source/js/ai-assistant.js 中的 API 地址更新为实际地址:

1
2
3
4
var CONFIG = {
apiEndpoint: 'https://bytefisher-ai.vercel.app/api/chat',
// ↑ 替换为你的 Vercel 项目 URL
};

7.3 验证 API

用 curl 测试 API 是否正常工作:

1
2
3
curl -X POST https://bytefisher-ai.vercel.app/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"你好"}]}'

期望返回格式:

1
2
3
4
5
6
7
8
9
10
{
"choices": [
{
"message": {
"content": "你好!欢迎来到 ByteFisher 博客...",
"role": "assistant"
}
}
]
}

浏览器直接 GET 访问 API 应返回 {"error":"Method not allowed"},这是正常的(只接受 POST)。

7.4 触发重新部署

Vercel 默认自动连接 GitHub,推送代码后自动重新部署。如果初始项目创建时 api/ 目录还不存在,可能不会自动检测变化,需要手动 Redeploy:

1
Vercel Dashboard → 项目 → Deployments → 最新 commit → Redeploy

八、完整交互流程

用户操作链路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
打开博客首页
→ 右下角出现 🎣 悬浮球
→ 鼠标悬停 → 展开显示 "AI 助手"
→ 点击悬浮球 → 面板弹出,按钮隐藏
→ 显示欢迎语:
"🎣 欢迎来到 ByteFisher 博客!

我是 ByteBot,可以帮你:
📖 推荐文章
💡 解答技术问题
🎯 了解博客内容

有什么想了解的?"
→ 输入 "你们博客有哪些 Unity 文章?"
→ 按 Enter → 三点跳动加载
→ AI 回复(支持代码块、加粗等 Markdown 渲染)
→ 按 Escape 或点击 × 关闭面板
→ 按钮恢复显示

数据流转时序:

1
2
3
4
5
6
7
8
9
10
11
12
13
Frontend                   Vercel Proxy              DeepSeek API
│ │ │
├── POST /api/chat ────────┤ │
│ { messages: [...] } │ │
│ ├── POST /v1/chat/completions ──┤
│ │ Authorization: Bearer │
│ │ Body: { messages, ... } │
│ │ │
│ │ ←── 200 JSON ──────────┤
│ ←── 200 JSON ──────────┤ { choices: [...] } │
│ { choices: [...] } │ │
│ │ │
│ 渲染消息到面板 │ │

错误处理路径:

故障场景 表现 用户看到
网络断开 fetch 超时 “网络开小差了,请稍后重试 🐟”
API Key 无效 DeepSeek 返回 401 “抱歉没理解,换个问法试试?”
请求超时 Vercel 10s 超时 “网络开小差了,请稍后重试 🐟”
参数错误 前端校验 不发送请求,提示用户输入内容

九、费用与性能评估

费用估算

项目 计算公式 月费
DeepSeek 输入 ¥0.14/百万tokens × 4.5万tokens/月 ≈ ¥0.0063
DeepSeek 输出 ¥0.28/百万tokens × 0.5万tokens/月 ≈ ¥0.0014
Vercel 托管 Hobby 计划免费额度 ¥0
总计 日均 30 次问答 ≈ ¥0.01/月

按日均 30 次问答,每次约 1500 tokens(含 system prompt)计算。DeepSeek 的价格极低,几乎可以忽略不计。

性能指标

阶段 耗时 说明
Vercel 冷启动 0.5-2s 闲置 15 分钟后首次请求
DeepSeek 推理 0.8-2s 模型响应时间
网络传输 0.2-0.5s 客户端 → Vercel → DeepSeek
总耗时 1.5-4.5s 冷启动时更慢,后续请求更快

对于个人博客的流量,冷启动不可避免。Vercel Hobby 计划在 15 分钟无请求后会回收实例。但 1-3s 的等待对问答场景来说可以接受。


十、总结

改造前后对比

维度 之前 之后
互动方式 评论区留言,等待回复 AI 即时问答
覆盖范围 仅文章底部 全站右下角悬浮球
回答问题 博主自己回复 DeepSeek 大模型
技术栈 DeepSeek + Vercel + 原生 JS
月运营成本 ≈ ¥0.01

技术收获

整个实现过程中的几个关键经验:

  1. Vercel Serverless 函数要用 CommonJSmodule.exports 而非 export default
  2. CORS 要动态检测 origin:本地开发(localhost)和生产环境(bytefisher.top)需要不同允许来源
  3. PJAX 兼容要防重复创建data-pjax 脚本每次导航都执行,需要在 init() 中加守卫检查
  4. 纯前端 Markdown 渲染:不引入第三方库也能满足基本需求,HTML 转义顺序至关重要

可以做的下一步扩展

  • 对话历史:用 localStorage 持久化对话记录,刷新不丢失
  • RAG 检索增强:结合博客的 search.json 做文章检索,AI 可以基于博客内容回答,不依赖模型训练数据
  • 多人格切换:钓鱼助手、编程助手、闲聊助手三种模式
  • 语音输入:集成 Web Speech API,支持语音提问

本文所有代码托管在 GitHub,欢迎 Star 和交流。